Skip to content

feat(seaweedfs): add SeaweedFS object storage provider - #14160

Open
chrislusf wants to merge 69 commits into
apache:mainfrom
chrislusf:feat/seaweedfs-object-store-provider
Open

chrislusf wants to merge 69 commits into
apache:mainfrom
chrislusf:feat/seaweedfs-object-store-provider

Conversation

@chrislusf

@chrislusf chrislusf commented Sep 12, 2026

Copy link
Copy Markdown

Summary

Adds SeaweedFS as a first-class object storage provider in CloudStack, alongside the existing MinIO, Ceph RGW, and Cloudian HyperStore providers.

SeaweedFS exposes an S3-compatible API and an AWS IAM-compatible API, so this provider uses the AWS S3 and IAM Java SDKs — the same approach as the Cloudian HyperStore provider. No proprietary admin client is needed.

Key features

  • Bucket CRUD, policy, versioning, encryption, ACLs via AmazonS3 SDK (AWS SDK v1, same as Ceph/Cloudian)
  • Per-account IAM user provisioning via AmazonIdentityManagement SDK (same as Cloudian HyperStore)
  • Per-bucket quota via the SeaweedFS S3 ?seaweedfs-quota extension (PUT /{bucket}?seaweedfs-quota), authenticated via SigV4 and authorized via the s3:PutBucketQuota IAM permission. This requires SeaweedFS PR Multiple guest networks deployment issue #11279 (merged).
  • Usage reporting via S3 ListObjectsV2 (MVP; Prometheus or SOSAPI capacity.xml recommended for production scale)

Architecture

The plugin follows the Cloudian HyperStore pattern almost line for line:

  • Same store-details keys: s3Url, iamUrl, accesskey, secretkey
  • iamUrl defaults to s3Url (SeaweedFS registers its IAM API at POST / on the same S3 endpoint)
  • Same IAM-user-with-restricted-policy pattern
  • Same Spring wiring

Quota management

SeaweedFS enforces bucket quota server-side (read-only flag when usage exceeds the limit). The configuration surface is a narrow S3 subresource — PUT /{bucket}?seaweedfs-quota — authenticated via standard S3 SigV4 and authorized via dedicated s3:PutBucketQuota/s3:GetBucketQuota IAM permissions. This avoids exposing the broad SeaweedFS admin API to CloudStack. The plugin signs the request with AWSS3V4Signer and sends it via java.net.http.HttpClient (the AWS S3 SDK doesn't natively support custom subresources).

Comparison with MinIO and Ceph

Concern MinIO Ceph RGW Cloudian SeaweedFS
Bucket CRUD MinioClient AmazonS3 AmazonS3 AmazonS3
User creation MinioAdminClient RgwAdmin AmazonIdentityManagement AmazonIdentityManagement
Per-bucket quota MinioAdminClient RgwAdmin not supported S3 extension (SigV4)
Usage reporting MinioAdminClient RgwAdmin Cloudian admin API S3 ListObjectsV2

Files

New module under plugins/storage/object/seaweedfs/:

  • pom.xml — Maven module
  • SeaweedFSObjectStoreProviderImpl.java — Spring provider registration
  • SeaweedFSObjectStoreLifeCycleImpl.java — Pool add/health-check, URL validation
  • SeaweedFSObjectStoreDriverImpl.java — Bucket + user ops via S3 + IAM SDK
  • SeaweedFSObjectStoreUtil.java — S3 + IAM client builders, constants, SigV4 quota request
  • Spring/module metadata
  • Tests (18 tests, all passing)

SeaweedFS dependency

Requires SeaweedFS with the ?seaweedfs-quota S3 extension (PR seaweedfs/seaweedfs#11279, merged). Without it, quota operations will fail with 404; all other operations (bucket CRUD, user provisioning, usage) work with any recent SeaweedFS release.

Test plan

  • Unit tests pass: mvn -pl plugins/storage/object/seaweedfs test (18 tests, 0 failures)
  • Manual: register a SeaweedFS object store via addObjectStoragePool with s3Url, accesskey, secretkey
  • Manual: create a bucket via CloudStack, verify it appears in SeaweedFS
  • Manual: set a bucket quota via CloudStack, verify it appears in SeaweedFS via weed shell s3.bucket.list
  • Manual: provision a user for a CloudStack account, verify IAM user appears in SeaweedFS
  • Manual: verify usage reporting returns bucket sizes

Add a new object storage provider plugin for SeaweedFS, alongside the
existing MinIO, Ceph RGW, and Cloudian HyperStore providers.

SeaweedFS exposes an S3-compatible API and an AWS IAM-compatible API, so
this provider uses the AWS S3 and IAM Java SDKs — the same approach as
the Cloudian HyperStore provider. No proprietary admin client is needed.

Key features:
- Bucket CRUD, policy, versioning, encryption, ACLs via AmazonS3 SDK
- Per-account IAM user provisioning via AmazonIdentityManagement SDK
- Per-bucket quota via the SeaweedFS S3 ?seaweedfs-quota extension
  (PUT /{bucket}?seaweedfs-quota), authenticated via SigV4 and authorized
  via the s3:PutBucketQuota IAM permission. This requires SeaweedFS PR
  apache#11279.
- Usage reporting via S3 ListObjectsV2 (MVP; Prometheus or SOSAPI
  capacity.xml recommended for production scale)

The service credential (accesskey/secretkey on the object store) is
granted only s3:PutBucketQuota and s3:GetBucketQuota via an IAM policy,
so it cannot delete buckets, manage users, or change cluster topology.

The plugin follows the Cloudian HyperStore pattern almost line for line:
same store-details keys (s3Url, iamUrl, accesskey, secretkey), same
IAM-user-with-restricted-policy pattern, same Spring wiring.
SeaweedFS registers its embedded IAM API at POST / on the same S3
endpoint (UnifiedPostHandler in s3api_server.go), not under /iam. The
AWS IAM SDK uses the Query protocol and POSTs to the endpoint root, so
defaulting iamUrl to <s3Url>/iam would send IAM operations to an
unregistered path. Default to s3Url instead; a separate iamUrl is only
needed for deployments running a standalone weed iam server.

Found by Greptile review on PR apache#11279.
…licy constant

Two issues found by CodeRabbit review on PR apache#11279:

1. S3Signer implements legacy S3 Signature Version 2, not SigV4.
   SeaweedFS expects SigV4. Replace with AWSS3V4Signer which implements
   AWS Signature Version 4. The seaweedfs-quota query parameter is
   included in the signed canonical query string.

2. SERVICE_CREDENTIAL_POLICY was a dead constant (never referenced)
   that claimed the service credential is scoped to only
   s3:PutBucketQuota/s3:GetBucketQuota. This contradicts the actual
   implementation, which uses the service credential (admin) for all
   driver operations: bucket CRUD, IAM user provisioning, and quota.
   Remove the dead constant and document the actual credential model.
Copilot AI lite review requested due to automatic review settings September 12, 2026 05:32
@boring-cyborg

boring-cyborg Bot commented Sep 12, 2026

Copy link
Copy Markdown

Congratulations on your first Pull Request and welcome to the Apache CloudStack community! If you have any issues or are unsure about any anything please check our Contribution Guide (https://github.com/apache/cloudstack/blob/main/CONTRIBUTING.md)
Here are some useful points:

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved packaging, lifecycle, IAM isolation, credential handling, and quota-signing issues remain.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds SeaweedFS as a CloudStack object-storage provider using S3/IAM APIs and SeaweedFS quota support.

Changes:

  • Implements provider lifecycle, bucket, IAM, quota, and usage operations.
  • Adds Maven and Spring module registration.
  • Adds provider and driver tests.
File summaries
File Description
plugins/storage/object/seaweedfs/src/test/java/org/apache/cloudstack/storage/datastore/provider/SeaweedFSObjectStoreProviderImplTest.java Tests provider registration.
plugins/storage/object/seaweedfs/src/test/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImplTest.java Tests driver behavior and quota handling.
plugins/storage/object/seaweedfs/src/main/resources/META-INF/cloudstack/storage-object-seaweedfs/spring-storage-object-seaweedfs-context.xml Registers Spring provider wiring.
plugins/storage/object/seaweedfs/src/main/resources/META-INF/cloudstack/storage-object-seaweedfs/module.properties Defines module metadata.
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java Builds clients and quota requests.
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/provider/SeaweedFSObjectStoreProviderImpl.java Registers the provider.
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/SeaweedFSObjectStoreLifeCycleImpl.java Handles pool lifecycle and validation.
plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java Implements storage and IAM operations.
plugins/storage/object/seaweedfs/pom.xml Defines module dependencies.
plugins/pom.xml Registers the SeaweedFS module.
Review details

Suppressed comments (4)

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:430

  • On any transient per-bucket S3 failure, this inserts 0. BucketApiServiceImpl treats returned values as authoritative and persists bucket and object-store usage, so a temporary timeout resets usage to zero and under-reports capacity. Propagate the failure for the whole store (or skip the store update) instead of publishing zero.
            } catch (AmazonClientException e) {
                logger.warn("Failed to get usage for bucket {}: {}", bucket.getName(), e.getMessage());
                bucketUsage.put(bucket.getName(), 0L);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:271

  • HttpClient.newHttpClient() and this request have no connect or request timeout. A stalled SeaweedFS endpoint can block the synchronous bucket create/update API worker indefinitely. Use a shared client with configured connect and request timeouts, preferably using the provider's existing timeout configuration.
            java.net.http.HttpClient client = java.net.http.HttpClient.newHttpClient();
            java.net.http.HttpResponse<String> response = client.send(reqBuilder.build(),
                    java.net.http.HttpResponse.BodyHandlers.ofString());

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:261

  • The request adds Content-Length to the SDK headers at line 243, then copies every header into java.net.http.HttpRequest.Builder. Java's HttpClient rejects Content-Length as a restricted header, so this loop can throw before the request is sent. Filter transport-managed headers (at least Content-Length, and Host if present) and let HttpClient generate them while signing the same body.
            for (java.util.Map.Entry<String, String> entry : request.getHeaders().entrySet()) {
                if (entry.getKey() != null && entry.getValue() != null) {
                    reqBuilder.header(entry.getKey(), entry.getValue());
                }

plugins/storage/object/seaweedfs/src/test/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImplTest.java:280

  • The common setUp already populates storeDetailsMap with the S3 URL and both credentials, so this test never exercises the missing-configuration guard; it passes only because the real HTTP call fails. Clear or override the details map and assert the validation message before any network call.
        // No S3 URL/credentials configured — should throw with a clear message
        assertThrows(CloudRuntimeException.class, () -> driver.setBucketQuota(bucketTO, TEST_STORE_ID, 10));
  • Files reviewed: 10/10 changed files
  • Comments generated: 6
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread plugins/pom.xml
<module>storage/object/minio</module>
<module>storage/object/ceph</module>
<module>storage/object/cloudian</module>
<module>storage/object/seaweedfs</module>
@chrislusf
chrislusf marked this pull request as draft September 12, 2026 05:58
- ship provider in client packaging (add cloud-plugin-storage-object-seaweedfs
  to client/pom.xml), matching the other object-storage providers
- copy the size param through initialize() so ObjectStoreHelper no longer
  NPEs on addObjectStoragePool
- sign ?seaweedfs-quota as a canonical query parameter (split path/query,
  addParameter before signing) instead of leaving it unsigned in the URI
- skip restricted HTTP headers (Content-Length/Host/...) when copying signed
  headers onto the java.net.http request
- make the S3-extension HttpClient injectable so quota tests assert the
  signed path/headers/body without hitting the network
- createUser now reuses a stored IAM access key when it still exists in IAM,
  only creating a replacement (after cleaning up unmanaged leftover keys)
  when the stored key is gone, preventing credential rotation and IAM
  access-key limits
- rewrite quota tests to assert the signed request via a mock HttpClient;
  add createUser reuse and replacement tests
@chrislusf
chrislusf marked this pull request as ready for review September 15, 2026 00:25
Copilot AI review requested due to automatic review settings September 15, 2026 00:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved moderate and critical issues remain in quota handling, credential management, usage reporting, endpoint updates, and concurrency.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (10)

client/pom.xml:669

  • The existing Add Object Storage UI hard-codes its provider list and has no SeaweedFS entry (ui/src/views/infra/AddObjectStorage.vue:130), so adding this dependency still leaves the provider unavailable through the supported UI. Add the provider to the UI (the default URL fields already match this lifecycle) or explicitly limit the feature to API registration.
        <dependency>
            <groupId>org.apache.cloudstack</groupId>
            <artifactId>cloud-plugin-storage-object-seaweedfs</artifactId>
            <version>${project.version}</version>
        </dependency>

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:520

  • initialize always persists a resolved s3Url in the object-store details, so this branch will return that stale value after updateObjectStore changes ObjectStoreVO.url. The generic update path then calls listBuckets() against the old endpoint and can accept an unreachable new URL; subsequent bucket operations continue using the old endpoint. Synchronize the detail URL when the store URL changes or use the current store URL for this provider.
        Map<String, String> storeDetails = _storeDetailsDao.getDetails(storeId);
        String s3Url = storeDetails.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_S3_URL);
        if (s3Url == null || s3Url.isEmpty()) {
            ObjectStoreVO store = _storeDao.findById(storeId);
            s3Url = store.getUrl();
        }

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:170

  • When the stored key is missing, this path creates a replacement and persists it only in account details. Existing BucketVO rows still contain the old pair; createBucketResponse exposes those row values, so previously created buckets continue to hand clients invalid credentials after rotation. Update all buckets for this store/account with the new key pair, as the Cloudian driver does.
        // Persist the credentials in the account details
        details.put(SeaweedFSObjectStoreUtil.KEY_ACCESS_KEY, key.getAccessKeyId());
        details.put(SeaweedFSObjectStoreUtil.KEY_SECRET_KEY, key.getSecretAccessKey());
        _accountDetailsDao.persist(accountId, details);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:500

  • ListObjectsV2 reports only current versions. For a versioned bucket, noncurrent object versions are omitted even though they still consume storage, so usage and BucketVO.size are undercounted after overwrites or deletes. Use a version-aware listing or a SeaweedFS usage endpoint when versioning is enabled.
                    for (com.amazonaws.services.s3.model.S3ObjectSummary summary : result.getObjectSummaries()) {
                        size += summary.getSize();
                    }

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:458

  • CloudStack's createBucket API makes quota required and calls setQuota even for a value of 0, so a SeaweedFS deployment without this extension receives a 404 here and the surrounding create flow rolls the bucket back. That contradicts the description that bucket CRUD still works without the extension; either make zero quota a no-op/handle unsupported servers, or make the extension an explicit hard prerequisite for bucket creation.
        SeaweedFSObjectStoreUtil.setBucketQuotaViaS3Extension(s3Url, accessKey, secretKey, bucket.getName(), size, getS3ExtensionHttpClient());

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:164

  • Two concurrent createUser calls for the same account can both observe no stored key and no IAM keys, then each create and persist a different key. The last persistence wins, leaving the other key unmanaged; later calls see the persisted key and never enter deleteUnmanagedAccessKeys, so the orphan can accumulate and eventually hit IAM key limits. Serialize per-account provisioning or re-list and reconcile keys after creation.
        CreateAccessKeyResult result = iamClient.createAccessKey(
                new CreateAccessKeyRequest().withUserName(userName));

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:74

  • s3:* on Resource: "*" is not a restricted per-account policy. The credentials persisted into each account's bucket record/response can read and write every bucket and, with SeaweedFS's new s3:PutBucketQuota action, change any tenant's quota; the deny only covers bucket creation/deletion. Scope account credentials to that account's buckets/object actions and keep quota/admin mutations on a separate service credential.
        "        \"s3:*\"\n" +
        "      ],\n" +
        "      \"Resource\": \"*\"\n" +

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:328

  • Only status codes >= 400 are treated as failures, so a 3xx response is reported as a successful quota update even though HttpClient.newHttpClient() does not follow redirects by default and the mutation was not applied. Accept only the 2xx success range here.
            if (response.statusCode() >= 400) {

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:58

  • AccountDetailsDao is account-scoped—its lookup and persistence APIs accept only accountId—while this driver provisions credentials for a specific storeId. If one account uses two SeaweedFS pools, the second provisioning overwrites these fixed keys; a later bucket creation in the first pool then reads and publishes the second pool's credentials. Namespace the keys by store ID or use store-scoped credential storage.
    public static final String KEY_ACCESS_KEY = "swfs_AccessKey";
    public static final String KEY_SECRET_KEY = "swfs_SecretKey";

plugins/storage/object/seaweedfs/src/test/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImplTest.java:345

  • setUp() always stubs getDetails(TEST_STORE_ID) with a valid URL and credentials, so this test does not exercise the missing-configuration path. It instead creates a real HttpClient and attempts a network request, allowing the test to pass for the wrong reason. Clear or override storeDetailsMap before the assertion so the exception is caused by missing credentials.
    public void testSetBucketQuotaNoS3ConfigThrows() {
        BucketTO bucketTO = mock(BucketTO.class);
        when(bucketTO.getName()).thenReturn(TEST_BUCKET_NAME);
        // No S3 URL/credentials configured — should throw with a clear message
        assertThrows(CloudRuntimeException.class, () -> driver.setBucketQuota(bucketTO, TEST_STORE_ID, 10));
  • Files reviewed: 11/11 changed files
  • Comments generated: 4
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 15, 2026 00:40
AccountDetailsDao is account-scoped, so fixed key names like
swfs_AccessKey meant a second SeaweedFS pool for the same account
would overwrite the first pool's credentials. Replace the fixed
constants with keyAccessKey(storeId)/keySecretKey(storeId) methods
that namespace by store ID.
When createUser creates a replacement IAM access key, existing BucketVO
rows still carried the old key pair, so previously created buckets kept
handing clients invalid credentials. Add updateAccountBucketCredentials
to update all bucket records for the store/account, mirroring the
Cloudian HyperStore driver.
…etail

initialize() persists a resolved s3Url in the object-store details, so
getS3Url returned that stale value after updateObjectStore changed
ObjectStoreVO.url. Bucket operations continued using the old endpoint.
Prefer the current store URL and fall back to the detail only if it is
missing.
Returning 0 for a failed S3 listing caused BucketApiServiceImpl to
overwrite the stored BucketVO.size with a false zero, erasing known
usage on a transient endpoint or permission failure. Omit the bucket
from the result map instead so the caller retains the previous value.
HttpClient.newHttpClient() had no connect timeout and the HttpRequest
had no per-request timeout, so a stalled or unreachable SeaweedFS
endpoint could block the synchronous bucket create/update API
indefinitely. Add a 10s connect timeout on the client and a 30s request
timeout on each HttpRequest.
Only status codes >= 400 were treated as failures, so a 3xx response
was reported as a successful quota update even though HttpClient does
not follow redirects by default and the mutation was not applied.
Accept only the 2xx range. Add a test asserting 3xx is rejected.
…list

The Add Object Storage view hard-codes its provider list and had no
SeaweedFS entry, so the provider was only available via API. Add it to
the dropdown; the default URL/accessKey/secretKey fields already match
this lifecycle.
…e details

setUp() always stubs getDetails with a valid URL and credentials, so
the test did not exercise the missing-configuration path — it created a
real HttpClient and attempted a network request, passing for the wrong
reason. Clear storeDetailsMap and null the store lookup so the exception
comes from the missing-config check.
The quota tests only checked that an Authorization header exists; they
did not verify the SigV4 canonical query, payload hash, or signed
headers against a known signature. A signing mismatch would therefore
pass the suite and make every quota operation fail against SeaweedFS.
Add a test that independently signs the same request through
AWSS3V4Signer and asserts the Authorization, x-amz-content-sha256, and
x-amz-date headers match exactly.
@chrislusf
chrislusf force-pushed the feat/seaweedfs-object-store-provider branch from bc00f66 to e353f6f Compare September 15, 2026 00:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Ten unresolved moderate findings affect credential consistency, endpoint handling, tenant isolation, quota validation, and test determinism.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (8)

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:172

  • Persisting the new key before updating BucketVOs creates a one-way inconsistency: if updateAccountBucketCredentials fails for any existing bucket, the key remains stored while some bucket rows still contain old credentials. The next invocation returns at lines 155-157 without retrying the repair, so those buckets can continue exposing invalid credentials. Make persistence and bucket updates transactional, or reconcile all bucket rows on the reuse path.
        _accountDetailsDao.persist(accountId, details);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:554

  • When iamUrl is omitted, initialize() stores it as the initial s3Url, but updateObjectStore only changes ObjectStoreVO.url. After a URL update, this accessor keeps IAM provisioning on the old endpoint while S3 operations use the new one, so account or bucket creation can fail or modify the wrong SeaweedFS instance. Track whether iamUrl was explicit or update this detail with the URL change.
    protected String getIAMUrl(long storeId) {
        Map<String, String> storeDetails = _storeDetailsDao.getDetails(storeId);
        return storeDetails.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_IAM_URL);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:207

  • iamAccessKeyExists only matches the key ID, so an IAM key whose status is Inactive is treated as usable. If an administrator disables the stored key, createUser returns true and new bucket records continue using credentials that cannot authenticate. Check AccessKeyMetadata.getStatus() and rotate/replace inactive keys before reusing them.
                if (accessKeyId.equals(metadata.getAccessKeyId())) {
                    return true;

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:157

  • The reuse check validates only the access-key ID. If the secret-key detail is missing or corrupted, this returns success and never repairs the credential pair, leaving newly created or existing bucket records unusable. Require both stored values before reusing the key; otherwise treat the pair as invalid and rotate it without preserving the unusable key.
        String storedAccessKeyId = details.get(accessKeyDetailKey);
        if (storedAccessKeyId != null && iamAccessKeyExists(iamClient, userName, storedAccessKeyId)) {
            logger.debug("Reusing existing IAM access key {} for user {}", storedAccessKeyId, userName);
            return true;

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:213

  • This catch treats every AmazonClientException from ListAccessKeys as evidence that the stored key is absent. A timeout, authentication failure, or temporary IAM outage therefore sends createUser into the replacement path, which can overwrite the persisted credentials, invalidate bucket records, or hit the IAM key limit. Propagate or retry lookup failures and rotate only after a successful listing that omits the stored ID.
        } catch (AmazonClientException e) {
            logger.warn("Failed to list IAM access keys for user {}: {}", userName, e.getMessage());
        }
        return false;

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:99

  • These IAM credentials are persisted for the account and exposed as bucket credentials, so this policy is the tenant boundary. Allowing s3:* on Resource: "*" gives every account access to every bucket in the SeaweedFS pool (including PutBucketQuota); denying only create/delete does not isolate accounts. Scope the policy to the account's bucket ARNs and update it as buckets are created or deleted, as the MinIO driver does.
        "      \"Action\": [\n" +
        "        \"s3:*\"\n" +
        "      ],\n" +
        "      \"Resource\": \"*\"\n" +

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:341

  • path always starts with /, so URI.resolve(path) replaces any path prefix in s3Url. For an endpoint behind a reverse proxy such as https://host/object-s3, the outgoing quota request is sent to /bucket instead of /object-s3/bucket, so it reaches the wrong route (and can fail signature verification). Build the outgoing URI while preserving the endpoint's existing path.
            java.net.URI fullUri = endpointUri.resolve(path);
            if (! queryString.isEmpty()) {
                fullUri = java.net.URI.create(fullUri.toString() + "?" + queryString);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:251

  • Negative quotas are treated as a disable request here, but updateBucketQuota persists the caller's negative value and computes resource accounting from it. Changing a 5-GiB bucket to -1 therefore frees 6 GiB and stores -1 even though the server quota was cleared. Reject negative values; only zero should disable.
        if (sizeGiB <= 0) {
  • Files reviewed: 12/12 changed files
  • Comments generated: 2
  • Review effort level: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved moderate issues affect bucket consistency, API responses, and usage accuracy.

Review details

Suppressed comments (5)

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:557

  • The lock is released here before BucketApiServiceImpl removes the BucketVO. A concurrent createUser/createBucket can acquire the lock after this block, observe the still-present row, and publish a policy that re-adds this bucket; the row is then removed, leaving a stale ARN grant. Since bucket names are reusable, the old account could access a later account's bucket with the same name. Keep row removal inside the same lock scope or otherwise serialize deletion with policy refresh.
        } finally {
            iamLock.unlock();
            iamLock.releaseRef();

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:439

  • Although this returns the updated BucketVO, BucketApiServiceImpl.createBucket discards that return value and ultimately returns its original bucket to CreateBucketCmd. The response generator therefore sees null or stale accessKey, secretKey, and bucketURL immediately after a successful create, even though these fields are populated in the database here and are part of BucketResponse. Propagate the returned BucketVO into the API response/state update.
            // Return the updated BucketVO (not the stale input bucket) so
            // BucketApiServiceImpl.createBucket does not overwrite the
            // persisted credentials with the stale values.
            return bucketVO;

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:774

  • SeaweedFS refreshes bucket_size_bytes only on the S3 instance holding its distributed s3.leader lock. If metricsUrl points to a load-balanced service, this request can hit a non-leader with stale or empty gauges; the parser then accepts those values and bypasses the S3 fallback, so CloudStack can under-report usage. Use a stable leader endpoint or an aggregated/freshness-checked metrics source.
            try {
                return SeaweedFSObjectStoreUtil.parseBucketUsageFromMetrics(
                        metricsUrl, bucketNames, getS3ExtensionHttpClient());

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:563

  • This check also matches Prometheus # HELP and # TYPE metadata lines, so a response with no bucket samples is accepted. The result was prefilled with zeroes and is then returned without the S3 fallback, causing every managed bucket to be reported as 0 bytes during an exporter response that only contains the metric declaration. Require an actual sample line (or otherwise treat a sample-less family as a scrape failure).
            if (!body.contains(METRIC_BUCKET_SIZE_BYTES)) {

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/util/SeaweedFSObjectStoreUtil.java:547

  • Appending /metrics directly makes a configured URL ending in / request //metrics. That can return a redirect or 404; HttpClient does not follow redirects here, so the driver silently falls back to the O(total objects) S3 scan on every usage poll and loses the scalability benefit of metricsUrl. Normalize trailing slashes before appending the endpoint path.
            java.net.URI uri = java.net.URI.create(metricsUrl + "/metrics");
  • Files reviewed: 16/16 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Three ways the metrics path could bypass the S3 fallback and report zero
usage for every managed bucket:

1. A metricsUrl ending in '/' produced a '//metrics' request, which can
   redirect or 404. HttpClient does not follow redirects here, so the
   driver silently fell back to the O(total objects) S3 scan on every
   usage poll, losing the scalability benefit. Trailing slashes are now
   normalized before appending the endpoint path.

2. The metric-family check matched Prometheus '# HELP' and '# TYPE'
   metadata lines, so a response declaring the family but exporting no
   samples was accepted and the prefilled zeroes were returned. Comment
   lines are now skipped.

3. SeaweedFS refreshes the bucket-size gauges only on the S3 instance
   holding the distributed s3.leader lock, so a load-balanced metricsUrl
   can hit a non-leader with empty gauges. Since SeaweedFS publishes a
   zero gauge for empty buckets, the leader always exports one sample per
   bucket it knows about; the parser now requires a sample for every
   managed bucket and raises a scrape failure otherwise, so the caller
   falls back to the accurate S3 listing.
The IAM lock was released before BucketApiServiceImpl removed the
BucketVO row. A concurrent createUser/createBucket could acquire the
lock in that window, observe the still-present row, and publish a policy
that re-added the deleted bucket ARN; the row was then removed, leaving
a stale grant. Since bucket names are reusable, the old account could
access a later account's bucket with the same name.

deleteBucket now removes the row itself, inside the lock and after the
policy refresh succeeds. A policy failure still propagates with the row
intact so the operation stays retryable and BucketApiServiceImpl leaves
its accounting alone. On success BucketApiServiceImpl's own
_bucketDao.remove is a no-op while its resource-limit and allocated-size
cleanup still runs.
createBucket captured the driver's return value only as a BucketTO and
then wrote the original in-memory bucket back with _bucketDao.update,
clobbering the access key, secret key, and bucket URL that the provider
had just persisted. The stale object was also returned to
CreateBucketCmd, so BucketResponse exposed null credentials immediately
after a successful create even though the database held the real values.

The row is now re-read before the state update so provider-persisted
fields survive and reach the API response. Providers such as SeaweedFS
and Cloudian HyperStore write per-account credentials to the BucketVO
themselves.
… removal

Four new tests:
- testDeleteBucketRemovesRowInsideLock: the driver removes the BucketVO
  itself so no concurrent policy rebuild can observe a stale row.
- testGetAllBucketsUsageMetadataOnlyFallsBackToList: a HELP/TYPE-only
  response (non-leader S3 instance) triggers the S3 fallback.
- testGetAllBucketsUsageMissingSampleFallsBackToList: a response missing
  a sample for one managed bucket triggers the S3 fallback.
- testGetAllBucketsUsageMetricsUrlTrailingSlashNormalized: a metricsUrl
  with a trailing slash requests /metrics, not //metrics.
Copilot AI review requested due to automatic review settings September 15, 2026 05:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved moderate issues affect deletion accounting, endpoint consistency, metrics preservation, and credential validation.

Review details

Suppressed comments (4)

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:568

  • This removes the BucketVO before BucketApiServiceImpl.deleteCheckedBucket updates resource counts and the object-store allocated size. If either later cleanup operation fails or returns false, the remote bucket and IAM grant have already been removed while CloudStack still accounts for the bucket/quota, and a retry cannot find the row to reconcile the state. Keep row deletion coupled to successful accounting, or coordinate the lock and caller cleanup so the row is not removed first.
            for (BucketVO bvo : _bucketDao.listByObjectStoreIdAndAccountId(storeId, accountId)) {
                if (bucketName.equals(bvo.getName())) {
                    _bucketDao.remove(bvo.getId());
                    break;
                }
            }

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/SeaweedFSObjectStoreLifeCycleImpl.java:96

  • Leaving the default endpoints out of the details makes getS3Url() follow a later updateObjectStore URL change, but BucketVO.bucketURL is written only during bucket creation. BucketResponse exposes that stored URL and the object-store browser uses it to construct its S3 client, so after changing the pool URL CloudStack operations use the new endpoint while the browser still targets the old one. Please update existing bucket URLs when the pool URL changes, or avoid relying on this fallback without keeping those values consistent.
        // driver falls back to ObjectStoreVO.url / getS3Url() when the
        // details are absent, so updateObjectStore can change the store URL
        // without a stale persisted s3Url/iamUrl overriding it.

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/SeaweedFSObjectStoreLifeCycleImpl.java:90

  • metricsUrl is accepted by the UI and sent in the API details map, but this initializer never reads or preserves it. Consequently ObjectStoreHelper.createObjectStore drops the configured metrics endpoint, getMetricsUrl() always returns null, and usage reporting always falls back to the O(total objects) ListObjectsV2 scan instead of using the advertised scalable path. Retain this detail (and remove it when omitted) alongside the S3/IAM endpoint overrides.
        String s3Url = details.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_S3_URL);
        String iamUrl = details.get(SeaweedFSObjectStoreUtil.STORE_DETAILS_KEY_IAM_URL);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/lifecycle/SeaweedFSObjectStoreLifeCycleImpl.java:138

  • These validation calls use the literal unknown credentials rather than the configured accessKey/secretKey (see validateS3Url/validateIAMUrl), so an object store with invalid admin credentials is accepted and only fails on the first bucket or IAM operation. Authenticate a validation request with the supplied credentials (or otherwise verify them) before persisting the pool, as the other providers do during initialization.
        SeaweedFSObjectStoreUtil.validateS3Url(s3Url);
        logger.info("Validating SeaweedFS IAM endpoint: {}", iamUrl);
        SeaweedFSObjectStoreUtil.validateIAMUrl(iamUrl);
  • Files reviewed: 16/16 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

…deleteBucket

Removing the BucketVO inside the IAM lock closed the stale-grant window
but broke deletion accounting: BucketApiServiceImpl.deleteCheckedBucket
decrements the resource counts and allocated size after the driver
returns, so if any of that failed the row was already gone and a retry
could not find it to reconcile, while CloudStack still accounted for the
bucket and quota.

deleteBucket now marks the row Bucket.State.Destroyed inside the lock and
buildAccountIAMPolicy skips Destroyed rows. The row survives for the
caller's accounting and for reconciliation on failure, while a concurrent
createUser/createBucket that rebuilds the policy from the DB can no
longer re-add the deleted bucket's ARN on a name that is now reusable.
…ation

The UI accepts metricsUrl and the API passes it in the details map, but
initialize() never read it, so ObjectStoreHelper.createObjectStore dropped
it. getMetricsUrl() therefore always returned null and usage reporting
always fell back to the O(total objects) ListObjectsV2 scan, meaning the
scalable metrics path could never actually be used. The detail is now
retained when supplied and removed when omitted, alongside the S3 and IAM
endpoint overrides.
…lization

validateS3Url and validateIAMUrl deliberately connect with the literal
'unknown' credentials to probe that the endpoint behaves like the
respective service, so on their own they accept an object store whose
admin access key or secret key is wrong; the failure only surfaced later
on the first bucket or IAM operation.

Added validateCredentials, which performs an authenticated listBuckets
and listUsers with the supplied credentials, and called it from
initialize() so a bad-credential store is rejected at registration time
as the other providers do.
BucketVO.bucketURL is written only at bucket creation. BucketResponse
exposes it and the object-store browser builds its S3 client from it, so
after updateObjectStore changed the pool URL the management server used
the new endpoint while the browser kept targeting the old one.

updateObjectStore now rewrites the bucketURL of every bucket on the store,
replacing the old URL prefix with the new one, once the new URL has been
verified accessible.
…nd credential validation

- testDeleteBucketMarksRowDestroyedInsideLock: the row is marked Destroyed
  inside the IAM lock and NOT removed, so the caller's accounting still has
  it.
- testBuildPolicySkipsDestroyedBuckets: a Destroyed bucket's ARN is left out
  of the account policy.
- testInitializeMetricsUrlPersisted / ...OmittedNotPersisted: the metrics
  endpoint survives initialization when supplied and is not persisted when
  omitted.
- testInitializeValidatesSuppliedCredentials: initialization authenticates
  with the configured admin credentials.
Copilot AI review requested due to automatic review settings September 15, 2026 06:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical bucket-isolation race and additional credential, quota-accounting, and endpoint-update issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (3)

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:232

  • AccountDetailsDao.persist expunges every existing detail for the account before inserting this map (engine/schema/src/main/java/com/cloud/user/AccountDetailsDaoImpl.java:74-85). This lock is keyed by storeId + accountId, so two pools provisioning the same CloudStack account concurrently can each read a snapshot and the later persist can delete the other store's namespaced credentials, breaking existing buckets. Serialize account-wide account-detail writes or replace this with an atomic per-key update.
        _accountDetailsDao.persist(accountId, details);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:743

  • This remote quota mutation is not atomic with CloudStack's resource accounting: BucketApiServiceImpl.updateBucketQuota calls setQuota before constructing its CheckedReservation. If an increase exceeds the account/store limit, the reservation fails after SeaweedFS has applied the new quota, while the BucketVO remains unchanged. Reserve first or add a compensating quota update on failure so the backend and accounting cannot diverge.
        boolean allowMissingExtension = !hasPositiveQuota(storeId, bucket);
        SeaweedFSObjectStoreUtil.setBucketQuotaViaS3Extension(s3Url, accessKey, secretKey, bucket.getName(), size,
                getS3ExtensionHttpClient(), allowMissingExtension);

server/src/main/java/com/cloud/storage/StorageManagerImpl.java:4802

  • This rewrites bucket URLs for every provider even when the pool has an explicit s3Url. For example, a SeaweedFS/Cloudian s3Url under the old generic URL is rewritten when only ObjectStoreVO.url changes, although the driver continues using the explicit endpoint; the browser then targets a different, invalid endpoint. Skip this rewrite when s3Url is configured, or rewrite based on the effective S3 endpoint rather than the generic store URL.
            updateBucketUrls(id, oldUrl, url);
  • Files reviewed: 17/17 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread server/src/main/java/com/cloud/storage/StorageManagerImpl.java Outdated
… other pools

AccountDetailsDao.persist(accountId, map) expunges every existing detail
for the account before inserting the supplied map, making it a
read-modify-write of the whole account detail set. The IAM lock is keyed by
storeId+accountId, so two object stores provisioning the same CloudStack
account concurrently could each read a snapshot and have the later persist
delete the other store's namespaced credentials, breaking every bucket
that referenced them. It would also drop unrelated account details.

createUser now writes each namespaced credential with
AccountDetailsDao.addDetail, which only touches the named key, so
concurrent provisioning of distinct stores cannot interfere. Tests assert
the per-key writes and that the account-wide persist is no longer used.
updateBucketQuota applied the backend quota before constructing its
CheckedReservation, so an increase that exceeded the account or object
store limit left the storage backend with the new quota while the BucketVO
and resource counts kept the old value.

The reservation is now taken first and the remote setQuota happens inside
it, so a limit failure makes no backend change. A compensating setQuota
restores the previous value if the resource-count or allocated-size update
fails after the remote call, on both the increase and decrease paths, and
bucket.setQuota is only applied once accounting has succeeded. Also
defaults a null existing quota to 0 instead of dereferencing it.
updateBucketUrls rewrote stored bucket URLs for every provider whenever
ObjectStoreVO.url changed. Providers that support an explicit s3Url detail
(SeaweedFS, Cloudian HyperStore) keep using that endpoint regardless of the
generic URL, so rewriting off the generic URL pointed the object store
browser at an endpoint the driver never uses. The rewrite is now skipped
when an s3Url detail is configured.
Copilot AI review requested due to automatic review settings September 15, 2026 06:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate correctness, accounting, URL, and authorization-safety issues remain.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (6)

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:750

  • hasPositiveQuota cannot distinguish initial creation from an update whose previous quota is zero. If an update from 0 to a positive quota succeeds remotely and the accounting update then fails, restoreRemoteQuota(..., previousQuota = 0) reaches this branch; a 404/405 during rollback is silently treated as a missing optional extension, even though the positive update already proved the extension was available. CloudStack can then retain quota 0 while SeaweedFS remains quota-limited/read-only. Only tolerate a missing extension for the initial create; rollback and updates must propagate the failure.
        boolean allowMissingExtension = !hasPositiveQuota(storeId, bucket);
        SeaweedFSObjectStoreUtil.setBucketQuotaViaS3Extension(s3Url, accessKey, secretKey, bucket.getName(), size,
                getS3ExtensionHttpClient(), allowMissingExtension);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:441

  • Because s3Url is user-configurable and not normalized, an otherwise valid endpoint ending in / produces ...//bucket here. That value is persisted in BucketVO.bucketURL and used by the browser, so such registrations create broken bucket URLs. Strip trailing slashes before appending the bucket path.
            bucketVO.setBucketURL(s3Url + "/" + bucketName);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:577

  • This lock is scoped to (storeId, accountId), so it does not serialize deletion here with createBucket for a different account. Once the S3 delete succeeds but before this policy refresh completes, another account can recreate the globally unique bucket name while the old account's policy still grants that ARN, allowing the old credentials to access the new tenant's bucket. Use a store-wide bucket-name lock shared by create/delete, or revoke the old grant before the name can be reused.
            AmazonIdentityManagement iamClient = getIAMClient(storeId);
            updateAccountIAMPolicyLocked(iamClient, storeId, accountId, bucketName);

server/src/main/java/com/cloud/storage/StorageManagerImpl.java:4851

  • newUrl is accepted with a trailing slash, while the stored suffix normally starts with /; this concatenation can rewrite every bucket URL to ...//bucket, breaking browser access after a pool URL update. Normalize the base URL before concatenating.
            bucket.setBucketURL(newUrl + bucketUrl.substring(oldUrl.length()));

server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java:368

  • This compensation only restores the remote quota, not CloudStack's accounting. incrementResourceCount can complete before updateAllocatedSize throws, and CheckedReservation.close() only removes the reservation; moreover updateAllocatedSize returns false on an unsuccessful DAO update instead of throwing, so the catch is skipped entirely. In either case the method can leave the resource count/allocated size inconsistent with the restored backend quota; check the Boolean result and explicitly compensate all accounting mutations before returning the error.
                resourceLimitManager.incrementResourceCount(bucket.getAccountId(), Resource.ResourceType.object_storage, diff);
                _objectStoreDao.updateAllocatedSize(objectStoreVO, diff);
            } catch (RuntimeException e) {
                restoreRemoteQuota(objectStore, bucketTO, bucket.getName(), previousQuota, e);

server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java:371

  • This assignment completes the remote/accounting side of the quota change, but the enclosing method persists BucketVO only afterward. If that later _bucketDao.update fails or returns false, the API can report an error or success while the remote quota, resource counters, and BucketVO disagree; add an outer compensation path or make the state/accounting update transactional with this change.
            bucket.setQuota(quota);
  • Files reviewed: 17/17 changed files
  • Comments generated: 3
  • Review effort level: Lite

// account rebuilds the policy from the DB and re-adds this bucket's
// ARN: buildAccountIAMPolicy skips Destroyed rows, so the grant
// cannot come back on a name that is now reusable.
markBucketDestroyed(storeId, accountId, bucketName);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 932cc3c. The driver still marks the bucket row Destroyed, but BucketApiServiceImpl.deleteCheckedBucket now performs the resource-count updates, allocated-size update, and row removal together in one transaction after the driver returns, checking the DAO results. That keeps retry paths from double-decrementing object-store accounting. Added a regression test for allocated-size failure.

…d delete

The IAM lock is scoped to (storeId, accountId), so it did not serialize a
deletion against createBucket for a *different* account. S3 bucket names
are globally unique per store and reusable, so once the S3 delete succeeded
but before the old owner's IAM policy refresh completed, another account
could recreate the name while the old owner's policy still granted that
ARN, letting the old credentials reach the new tenant's bucket.

createBucket and deleteBucket now both take a DB-backed GlobalLock keyed on
(storeId, bucketName) around their whole body, so the two can never
interleave for a given name. The existing per-account IAM lock is retained
inside for credential and policy serialization.
S3 endpoint URLs are operator-supplied and accepted with or without a
trailing slash, but two places appended a '/'-prefixed suffix to them
directly:

- createBucket persisted BucketVO.bucketURL as s3Url + "/" + bucketName,
  so an endpoint ending in '/' produced a broken '...//bucket' that both
  BucketResponse and the object store browser then used.
- StorageManagerImpl.updateBucketUrls concatenated the new base URL with
  the retained '/bucket' suffix, so a trailing slash on the new pool URL
  rewrote every bucket URL to '...//bucket'.

Added SeaweedFSObjectStoreUtil.stripTrailingSlashes and applied it in the
driver, reused it in the metrics scrape which had its own inline loop, and
normalized both bases in updateBucketUrls.
Copilot AI review requested due to automatic review settings September 15, 2026 07:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved issues affect persistence, cleanup, endpoint handling, and quota-update atomicity.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (5)

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:897

  • The state is changed in memory, but a failed DAO update is ignored. On a later retry (for example, after accounting cleanup failed), the persisted row can still look live; createUser will then rebuild the policy and re-grant this ARN even though the remote bucket was deleted and its name may have been reused by another account. Propagate the update failure before refreshing the IAM policy.
                    _bucketDao.update(bvo.getId(), bvo);

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:580

  • If this policy-refresh cleanup fails, the method still rethrows after only adding a suppressed exception. Because the caller assigns bucketCreated only when createBucket returns, it will skip its own deleteBucket cleanup, remove the CloudStack row, and never retry this IAM revocation. A later account can reuse the bucket name while the old account policy still grants the ARN, allowing stale credentials to access the new tenant's bucket. Keep the row/operation retryable until policy removal succeeds or invoke an idempotent cleanup path that guarantees the refresh is retried.
                logger.warn("Failed to revoke IAM policy for bucket {} after cleanup: {}", bucketName, policyEx.getMessage());

plugins/storage/object/seaweedfs/src/main/java/org/apache/cloudstack/storage/datastore/driver/SeaweedFSObjectStoreDriverImpl.java:546

  • This persists any path prefix from s3Url into BucketURL, but the existing object-store browser derives its MinIO endpoint from only the host and port and drops that prefix (ui/src/components/view/ObjectStoreBrowser.vue:451-456). A SeaweedFS deployment behind a path such as /s3 will work for server-side calls but browser listing/upload requests target the wrong endpoint. Either reject path-prefixed overrides or update the browser client construction to preserve the endpoint path.
            bucketVO.setBucketURL(SeaweedFSObjectStoreUtil.stripTrailingSlashes(s3Url) + "/" + bucketName);

server/src/main/java/com/cloud/storage/StorageManagerImpl.java:4857

  • This prefix check also matches unrelated endpoints that merely share the same text (for example, http://s3:83330/... when oldBase is http://s3:8333). The resulting suffix then corrupts that bucket's URL during an object-store update. Require the old base followed by the path separator before rewriting.
            if (bucketUrl == null || !bucketUrl.startsWith(oldBase)) {

server/src/main/java/org/apache/cloudstack/storage/object/BucketApiServiceImpl.java:337

  • The revised quota path can fail after the encryption/versioning/policy calls above have already changed the remote bucket—for example, the new reservation can be rejected for an over-limit request. This catch only wraps the exception, so the API can return an error while those remote settings remain changed and the BucketVO still has the old values. Roll back the earlier remote mutations or make the whole update atomic before reporting failure.
            boolean bucketPersisted = updateBucketQuota(cmd, bucket, objectStore, objectStoreVO, bucketTO);
  • Files reviewed: 17/17 changed files
  • Comments generated: 2
  • Review effort level: Lite

// trailing slash, which would persist a broken "...//bucket" URL
// that BucketResponse and the object store browser both use.
bucketVO.setBucketURL(SeaweedFSObjectStoreUtil.stripTrailingSlashes(s3Url) + "/" + bucketName);
_bucketDao.update(bucket.getId(), bucketVO);
}
String suffix = bucketUrl.substring(oldBase.length());
bucket.setBucketURL(newBase + (suffix.startsWith("/") ? suffix : "/" + suffix));
_bucketDao.update(bucket.getId(), bucket);
@chrislusf

Copy link
Copy Markdown
Author

Addressed Copilot review 5206611018.

  • Credential detail writes: fixed in 932cc3c; the access/secret pair now rolls back together and cleans up the newly-created IAM key on persistence failure.
  • Delete accounting idempotency: fixed in 932cc3c; bucket count, object_storage, allocated size, and row removal are handled together after the driver delete path returns.
  • Quota update accounting and BucketVO persistence: fixed in 932cc3c; updateAllocatedSize is checked, resource counts are compensated on failure, remote quota is restored, and quota persistence happens inside the quota helper.
  • Missing SeaweedFS quota extension: fixed in the branch by allowing missing extension only before the bucket has allocated state, not during update/rollback paths.
  • Bucket URL / storage URL trailing slash normalization: fixed in e76aef2.
  • Store-wide bucket-name locking for create/delete: fixed in c9eaf62.

Validated with:

  • mvn -pl plugins/storage/object/seaweedfs -Dtest=SeaweedFSObjectStoreDriverImplTest -Dnet.bytebuddy.experimental=true test
  • mvn -pl server -Dtest=BucketApiServiceImplTest -Dnet.bytebuddy.experimental=true test

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants